/**
 * Copyright 2008 Adam Ruggles.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package kiff.util.filter;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import kiff.exception.FilterException;

/**
 * Html Filter, removes elements and attributes that are not part of a white list.
 * @author Adam
 * @version $Id: HtmlFilter.java 78 2009-04-11 17:55:57Z a.ruggles $
 */
public class HtmlFilter implements TextFilter {
	/**
	 * Double Quote attribute pattern (attribute = "value").
	 */
	private final Pattern attrDblQuote = Pattern.compile("(?i)(?s)([a-z0-9]+)=\"(.*?)\"");

	/**
	 * No Quotes Attribute pattern (attribute = value).
	 */
	private final Pattern attrNoQuote = Pattern.compile("(?i)(?s)([a-z0-9]+)=([^\"^'\\s]+)");

	/**
	 * Single Quote attribute pattern (attribute = 'value').
	 */
	private final Pattern attrSglQuote = Pattern.compile("(?i)(?s)([a-z0-9]+)=([^\"\\s]+)");

	/**
	 * Element/Attribute map.  Used to store elements and an array of attributes that will pass through the filter.
	 */
	private Map<String, String[]> elements = new HashMap<String, String[]>();

	/**
	 * Pattern to find end tags (/attribute).
	 */
	private final Pattern endTag = Pattern.compile("(?i)(?s)^\\/([a-z0-9]+)");

	/**
	 * A list of element patterns where the element and any content within the element tags will be removed.
	 */
	private List<Pattern> removeElements = new ArrayList<Pattern>();

	/**
	 * Pattern to find element values and separate them from attributes. 
	 */
	private final Pattern startTag = Pattern.compile("(?i)(?s)^([a-z0-9]+)(.*?)(\\/?)$");

	/**
	 * Pattern to find element tags (&lt;attribute&gt;, &lt;/attribute&gt;).
	 */
	private final Pattern tag = Pattern.compile("(?i)(?s)<(.*?)>");

	/**
	 * Default Constructor.
	 */
	public HtmlFilter() { }

	/**
	 * Constructor that takes a Map of acceptable elements and attributes.
	 * @param acceptElements A Map containing valid elements and attribute pairs.
	 * @param removeElements An array of elements that should have their inner contents removed, including the type.
	 */
	public HtmlFilter(final Map<String, String> acceptElements, final String ... removeElements) {
		for (Map.Entry<String, String> entry : acceptElements.entrySet()) {
			this.addElement(entry.getKey(), entry.getValue());
		}
		for (String element : removeElements) {
			this.removeElements.add(Pattern.compile("<" + element + ".*?>(.*?)</" + element + ".*?>",
					Pattern.CASE_INSENSITIVE | Pattern.DOTALL));
		}
	}

	/**
	 * Adds a new element to the tags that should be allowed through the filter.
	 * @param element The element type name.
	 * @param attributes A comma delimited list of attributes for the element that should be allowed or null for none.
	 */
	public void addElement(final String element, final String attributes) {
		if (attributes == null) {
			elements.put(element, null);
		} else {
			// Parse the comma delimited list of attributes, make them all lower case and sort them.
			String[] attrs = attributes.split("\\s*,\\s*");
			for (int i = 0; i < attrs.length; i++) {
				attrs[i] = attrs[i].toLowerCase();
			}
			Arrays.sort(attrs);
			elements.put(element, attrs);
		}
	}

	/**
	 * Adds a new element to the tags that should be allowed through the filter.
	 * @param element The element type name.
	 * @param attributes An array of attributes for the element that should be allowed or null for none.
	 */
	public void addElement(final String element, final String[] attributes) {
		// Take the array of attributes, make them all lower case and sort them.
		for (int i = 0; i < attributes.length; i++) {
			attributes[i] = attributes[i].toLowerCase();
		}
		Arrays.sort(attributes);
		elements.put(element, attributes);
	}

	/**
	 * Returns whether or not a given attribute is allowed for a given element.
	 * @param element The element or type name.
	 * @param attribute The attribute to validate.
	 * @return True if the attribute is allowed, otherwise false.
	 */
	private boolean allowAttribute(final String element, final String attribute) {
		String[] validAttributes = elements.get(element);
		if (validAttributes != null) {
			// Search for the attribute and return true if it was found.
			return (Arrays.binarySearch(validAttributes, attribute) >= 0);
		}
		return false;
	}

	/**
	 * Builds the allowable attribute pairs and returns them.
	 * @param matcher The RegEx matcher to search and build the attribute pares.
	 * @return The allowable attribute pairs.
	 */
	private String buildAttrPairs(final Matcher matcher, final String element) {
		StringBuilder sb = new StringBuilder();
		while (matcher.find()) {
			String attribute = matcher.group(1).toLowerCase();
			if (allowAttribute(element, attribute)) {
				sb.append(' ').append(attribute)
				.append("=\"").append(matcher.group(2)).append('"');
			}
		}
		return sb.toString();
	}

	/**
	 * {@inheritDoc}
	 * @see kiff.util.filter.TextFilter#filter(java.lang.String)
	 */
	public String filter(final String text) throws FilterException {
		String temp = text;
		for (Pattern pattern : removeElements) {
			temp = pattern.matcher(temp).replaceAll("");
		}
		Matcher matcher = tag.matcher(temp);
		StringBuffer sb = new StringBuffer();
		while (matcher.find()) {
			matcher.appendReplacement(sb, sanitizeTag(matcher.group(1)));
		}
		matcher.appendTail(sb);
		return sb.toString();
	}

	/**
	 * Removes an element from the tags that should be allowed through the filter.
	 * @param element The element type name.
	 */
	public void removeElement(final String element) {
		elements.remove(element);
	}

	/**
	 * Checks for allowable tags and attributes and returns them if they are accepted.
	 * @param fullTag The full type with attributes.
	 * @return A String containing the element if allowed and any allowable attribute.
	 */
	private String sanitizeTag(final String fullTag) {
		 // Handle the end tags.
		if (fullTag.charAt(0) == '/') {
			Matcher matcher = endTag.matcher(fullTag);
			if (matcher.find() && elements.containsKey(matcher.group(1))) {
				StringBuilder builder = new StringBuilder();
				builder.append("</").append(matcher.group(1).toLowerCase()).append(">");
				return builder.toString();
			} else {
				return "";
			}
		} 
		// Handle the start tags.
		Matcher elemMatcher = startTag.matcher(fullTag);
		if (elemMatcher.find()) {
			String name = elemMatcher.group(1);
			String attributes = elemMatcher.group(2);
			String ending = elemMatcher.group(3);
			if (elements.containsKey(name)) {
				StringBuilder builder = new StringBuilder();
				builder.append("<").append(name);
				Matcher attrMatcher = attrDblQuote.matcher(attributes);
				builder.append(buildAttrPairs(attrMatcher, name));
				attrMatcher = attrSglQuote.matcher(attributes);
				builder.append(buildAttrPairs(attrMatcher, name));
				attrMatcher = attrNoQuote.matcher(attributes);
				builder.append(buildAttrPairs(attrMatcher, name));
				builder.append(ending).append(">");
				return builder.toString();
			} else {
				return "";
			}
		}
		return "";
	}
}
